iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 22

Day 22 -「安泰俄斯」識破 Monster Method 的真面目(下):解決之道

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260821/20182564OjriPW7ZFT.png

安泰俄斯是海神波塞頓與大地女神蓋亞之子,他住在利比亞,專門逼路過的陌生人和他摔角,再把輸掉的人殺死。偏偏這場比賽從一開始就不公平,安泰俄斯只要碰到大地,力量就會再次湧上來,不管被摔倒幾次都能重新站起來。

後來,海克力士為了尋找金蘋果穿過利比亞,當然也被安泰俄斯攔了下來。

海克力士把他摔倒,對方站起來,再摔一次,又站起來,每次安泰俄斯的身體碰到地面,剛才耗掉的力氣就像全部補回來一樣,這到底要打到什麼時候?

海克力士終於不再把安泰俄斯往地上摔,他直接抱住對方,把整個人舉離地面。失去大地補充力量後,安泰俄斯再也沒有還手之力,最後在半空中制服並殺死。

條列式與糾纏式的外觀已經分清楚,今天就沿用同一個順序動手。兩種怪獸的外觀不同,下手的方式自然也不一樣,不過節奏會很接近,在開始前先用特徵測試保護現有行為,今天使用的範例程式碼以及測試都先整理在 GitHub Refactoring Branch 中了,正文就不再一一贅述,直接從重構開始。

我們今天要做的不是跟安泰俄斯硬碰硬,試著先 Nerf 他,我們再來處理他。


條列:讓步驟浮現

CalculateParkingFee 已經看得出流程順序,只是每一段還被資料牽在一起,Michael Feathers 把這種找出連續工作片段、將 condition 與它控制的 body 一起抽取成高階步驟的做法稱為 Find Sequences

至於第一步要從哪裡開始,Feathers 還提供另一種判斷方式 Extract What You Know

先抽看得懂的

做法是只挑自己能明確說出輸入、輸出與用途的兩三行,最多先處理五行,不要一開始就圈選一大塊自己也講不清楚的區域。

這時可以用 Coupling Count(耦合數) 評估手工抽取的資料邊界,它計算有多少 value 經過新 method 的介面傳入或傳出,例如 CalculatePlatformFee 有一個 DateTime 傳入、一個 decimal 傳回,所以 Coupling Count 就是 2。

進行搬移時,通常先找 Coupling Count 低、容易核對的區塊。這不是要追求某個標準數字,只要片段夠小、我們又能完整說明就可以,不要為了讓數字漂亮反過來扭曲設計。

有 IDE 重構工具後,不必把 Coupling Count 當成每次都要手算,可以看工具產生的 signature,如果一次出現七八個參數與多個輸出,那就要提醒自己可能跨越太多段落了。

先挑獨立的區塊

先不要一口氣把全部選起來,我們從相對獨立、邏輯與變數都集中的週末平台費開始,因為這一段只需要進場日期,最後產生一個 decimal 金額:

decimal platformFee = 0m;
if (ticket.EntryTime.DayOfWeek == DayOfWeek.Saturday
    || ticket.EntryTime.DayOfWeek == DayOfWeek.Sunday)
    platformFee = 80m;

執行 Extract Method,替它取一個直接描述結果的名字 CalculatePlatformFee,確認工具整理出的輸入與回傳值後,程式碼會變成這樣:

var platformFee = CalculatePlatformFee(ticket.EntryTime);

private static decimal CalculatePlatformFee(DateTime entryTime)
    => entryTime.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
        ? 80m
        : 0m;

主線慢慢浮出來

接下來用同樣節奏處理基本費、夜間加成與管理費。月租折扣需要單價、計費單位等資訊,資料牽得比較多,那就先放著,不必硬著頭皮照程式執行順序一路抽到底,重構順序本來就不必等於執行順序。

每次只抽一段、編譯、跑測試,幾輪以後,原 method 的高階流程就會慢慢浮現出來:

public decimal CalculateParkingFee(string ticketId, DateTime exitTime)
{
    var ticket = _ticketRepo.Get(ticketId);
    var lot = _lotRepo.Get(ticket.LotId);
    // 計時以 30 分鐘為單位無條件進位,刷卡機精度只到半小時
    var units = Math.Ceiling((exitTime - ticket.EntryTime).TotalMinutes / 30.0);
    var member = _memberRepo.FindByPlate(ticket.LicensePlate);
        
    var baseFee = CalculateBaseFee(lot, units);
    var platformFee = CalculatePlatformFee(ticket.EntryTime);
    var nightSurcharge = CalculateNightSurcharge(ticket.EntryTime, baseFee);
    var memberDiscount = CalculateMemberDiscount(member, lot.UnitRate, units);
    var managementFee = CalculateManagementFee(baseFee);

    return baseFee + platformFee + nightSurcharge + managementFee - memberDiscount;
}

現在不用先知道每個計算細節,也能看懂停車費由哪些部分組成,在這種流程已經排成一列的 method 裡,Extract Method 通常是成本低、效益又高的重構手法。

讓計費有自己的邊界

高階流程浮出來之後,我們會發現 ParkingFeeService 同時負責兩種工作,從 Repository 取得這次停車資料,再使用這些資料完成計算。

這兩件事可以分開,目的不是假裝資料彼此毫無關係,而是替它們找到比較合適的主人,所以我們先將運算的職責分離,程式碼如下:

public decimal CalculateParkingFee(string ticketId, DateTime exitTime)
{
    var ticket = _ticketRepo.Get(ticketId);
    var lot = _lotRepo.Get(ticket.LotId);
    // 計時以 30 分鐘為單位無條件進位,刷卡機精度只到半小時
    var units = Math.Ceiling((exitTime - ticket.EntryTime).TotalMinutes / 30.0);
    var member = _memberRepo.FindByPlate(ticket.LicensePlate);
        
    return Execute(lot, units, ticket, member);
}

接著把這些計算搬進 ParkingFeeCalculator,剛搬完時 Execute 還得接收 lotunitsticketmember,每一個小 method 也跟著帶著同一批參數,資料只是換地方排隊,還沒有真正形成邊界。

這些值都屬於同一次停車費計算,所以可以透過建構子交給 ParkingFeeCalculator,再用 Introduce Field 逐一收成欄位,讓各個計算 method 直接使用這次執行的資料。整理後會變成這樣:

public class ParkingFeeCalculator
{
    private readonly ParkingLot _lot;
    private readonly double _units;
    private readonly ParkingTicket _ticket;
    private readonly Member? _member;
    private readonly decimal _baseFee;

    public ParkingFeeCalculator(ParkingTicket ticket, Member? member, ParkingLot lot, double units)
    {
        _member = member;
        _ticket = ticket;
        _units = units;
        _lot = lot;
        _baseFee = CalculateBaseFee();
    }

    public decimal Execute()
    {
        var platformFee = CalculatePlatformFee();
        var nightSurcharge = CalculateNightSurcharge();
        var memberDiscount = CalculateMemberDiscount();
        var managementFee = CalculateManagementFee();

        var totalFee = _baseFee + platformFee + nightSurcharge + managementFee - memberDiscount;
        return totalFee;
    }
    // ...其餘計算略
}

主程式整理後只負責準備資料與啟動計算:

public decimal CalculateParkingFee(string ticketId, DateTime exitTime)
{
    var calculator = BuildCalculator(ticketId, exitTime);
    return calculator.Execute();
}

private ParkingFeeCalculator BuildCalculator(string ticketId, DateTime exitTime)
{
    var ticket = _ticketRepo.Get(ticketId);
    var lot = _lotRepo.Get(ticket.LotId);
    // 計時以 30 分鐘為單位無條件進位,刷卡機精度只到半小時
    var units = Math.Ceiling((exitTime - ticket.EntryTime).TotalMinutes / 30.0);
    var member = _memberRepo.FindByPlate(ticket.LicensePlate);
    return new ParkingFeeCalculator(ticket, member, lot, units);
}

走到這裡,其實我們做的就是 Feathers 所提到的 Break Out Method Object:建立一個物件,專門承接原本 Monster Method 的一次執行。

原 method 需要的資料成為 constructor parameters,工作搬進 Execute(),原本散在 method 裡的 local variables 則逐步收成 fields,這些資料耦合被放進生命週期更清楚的邊界,後續抽取時也不必反覆傳遞同一長串參數。

還能繼續做什麼?

當然,還有很多事情可以做:

ParkingFeeCalculator 裡的每一種費用,也可以進一步抽成獨立的 IFeeRule,讓計費器組合多個規則完成計算,如果未來需要替換整套計費演算法,可以再考慮 Strategy

BuildCalculator 也能提煉成 Factory,讓建立計算器的邏輯獨立替換,費率若要改由後台設定,則可以把費率表抽出來透過 DI 注入。

能繼續做的事情很多,但在 Legacy Code 中,我們的目的不是把程式碼推到某種理論上的完美型態,現在這份 code 已經能讓下一個需求安全地進來,要改的位置找得到、影響範圍可控、測試也能確認行為沒有漂移,就很足夠了,至少跟一開始相比,我們已經改善很多。

所以重構完成後問一下自己,甚至找團隊夥伴一起來說故事,現在看得懂嗎?下一個需求進來時,這個邊界真的比較好改嗎?大家有共識就先停手。

在 Legacy Code 裡,重構的起點是下一個需求,不是今天對未來的想像。


糾纏:把骨架顯現

接著回到 ApplyMonthlyPass,它的困難不是看不出處理順序,而是每一條路徑都藏在另一個 branch 裡,這時候的第一個目標不是列出 sequence,而是先替最外層決策取名字。

Michael Feathers 把這個改動方向稱為 Skeletonize Methods,分別抽取出 condition 與它所控制的 body,讓原 method 最後只留下「如果什麼成立,就做什麼」的控制骨架,跟條列式的做法很像的地方就是我們一樣要透過封裝讓高階語言出現。

一樣先抽取方法

Extract Method 如果用得好,真的是 CP 值最高的重構手法之一,我們先把日期、A 區是否額滿,以及不同申請人的升等流程抽成有名字的 method,幾輪之後,ApplyMonthlyPass 會變成這樣:

public MonthlyPassResult ApplyMonthlyPass(MonthlyPassRequest request)
{
    var result = new MonthlyPassResult();
    if (IsValidStartDate(request.StartDate))
    {
        var slotsA = _slotRepo.GetAvailable(Zone.A, request.StartDate);
        if (IsZoneAFull(slotsA))
        {
            var slotsB = _slotRepo.GetAvailable(Zone.B, request.StartDate);
            foreach (var slot in slotsB)
            {
                if (slot.AllowsUpgrade)
                {
                    switch (request.ApplicantType)
                    {
                        case ApplicantType.Corporate:
                            result = TryCorporateUpgrade(request, slot);
                            break;
                        case ApplicantType.Residential:
                            result = TryResidentialUpgrade(request, slot);
                            break;
                    }
                }

                if (result.Status == MonthlyPassStatus.Held) break;
            }
        }
        else
        {
           result = HoldZoneASlot(slotsA[0]);
        }
    }

    return result;
}

現在已經能從主程式讀出整個決策分支:

開始申請
└── 開始日期有效
    ├── A 區還有車位 → 直接保留 A 區車位
    └── A 區額滿
        └── 逐一檢查允許升等的 B 區車位
            ├── 法人戶 → 檢查法人升等條件
            ├── 住戶 → 檢查住戶升等條件
            └── 成功保留車位後停止搜尋

這就是透過 Skeletonize Methods 技術後能得到的結果,原 method 只保留控制骨架,每個 condition 是一個能回答「是否成立」的問題,每個 body 則是有名字的動作,行數可能沒有立刻少很多,但我們至少不用在腦中一邊數大括號,光是這點就差很多了。

Guard Clause:請你出去

我們接下來會做一個重構,正式名稱是 Replace Nested Conditional with Guard Clauses,我喜歡叫它「反閘(NOT GATE)」,也沒有什麼原因,第一次看到這樣的做法的時候,不知道爲什麼第一個想到的是反向器 XD

先看一個最單純的例子:

if (member != null)
{
    if (member.IsActive)
    {
        SendCoupon(member);
    }
}

把兩層條件反轉後:

if (member == null) return;
if (!member.IsActive) return;

SendCoupon(member);

這樣就不是問「有沒有資格?」,而是不符合資格的就直接請出去。

照著這個思路,我們回到程式碼,可以看到骨架雖然顯現出來了,但巢狀結構仍然還是在,這時如果我們可以把不符合主線的條件反轉,提早 return,日期不合法就直接回傳,剩下的巢狀結構就會減少許多。

public MonthlyPassResult ApplyMonthlyPass(MonthlyPassRequest request)
{
    var result = new MonthlyPassResult();
    if (!IsValidStartDate(request.StartDate)) return result;

    var slotsA = _slotRepo.GetAvailable(Zone.A, request.StartDate);
    if (slotsA.Count > 0) return HoldZoneASlot(slotsA[0]);

    var slotsB = _slotRepo.GetAvailable(Zone.B, request.StartDate);
    foreach (var slot in slotsB.Where(slot => slot.AllowsUpgrade))
    {
        switch (request.ApplicantType)
        {
            case ApplicantType.Corporate:
                result = TryCorporateUpgrade(request, slot);
                break;
            case ApplicantType.Residential:
                result = TryResidentialUpgrade(request, slot);
                break;
        }

        if (result.Status == MonthlyPassStatus.Held) break;
    }

    return result;
}

調整後的行為沒有變,閱讀方向卻從「條件成立才有資格」,變成「不符合資格就不看了」,閱讀成本真的會減少很多,這個技巧相當實用。


總結

以上兩種策略也不是互斥的,先用 Skeletonize Methods 露出決策骨架後,某個 method 裡可能又是一串連續步驟,這時可以回頭用 Find Sequences,反過來,條列式的某個步驟裡如果藏著巢狀分支,也能局部改用 Skeletonize Methods,所以即使是面對到混合型,也能依靠技巧一步一步見招拆招。

不管從哪裡開始,節奏都不會差太多,重點還是得要先觀察,並挑一段自己說得清楚而且成本低的地方做抽取,每完成一步就編譯、跑測試,再從頭讀一次,到一個段落再接著觀察。

如果抽取後的 signature 反而膨脹出一大串參數,甚至比原本更難理解,那就退回去換一個切法,重做一次不會白忙,而是可以讓我們理解得更完整一點。

明天我們繼續看:有些行為不在程式碼裡,它們在資料庫裡,那該怎麼辦?

Reference


上一篇
Day 21 -「斯庫拉與卡律布狄斯」識破 Monster Method 的真面目(上):條列與糾纏
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言